D:\a\csshw\csshw\xtask\src\release.rs
Line | Count | Source |
1 | | //! Release preparation and git tag creation. |
2 | | //! |
3 | | //! [`prepare_release`] bumps the version, optionally creates a maintenance |
4 | | //! branch, updates `Cargo.toml` and `Cargo.lock`, generates the changelog, |
5 | | //! commits, and pushes. |
6 | | //! |
7 | | //! [`create_release_tag`] validates the current state and creates an annotated |
8 | | //! git tag that triggers the GitHub Actions release workflow. |
9 | | |
10 | | use anyhow::{bail, Context, Result}; |
11 | | use semver::Version; |
12 | | |
13 | | /// Type of version increment for a release. |
14 | | #[derive(Debug, PartialEq)] |
15 | | pub enum ReleaseType { |
16 | | /// Increment the major component (X.0.0). |
17 | | Major, |
18 | | /// Increment the minor component (0.X.0). |
19 | | Minor, |
20 | | /// Increment the patch component (0.0.X). |
21 | | Patch, |
22 | | } |
23 | | |
24 | | impl std::fmt::Display for ReleaseType { |
25 | 4 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
26 | 4 | match self { |
27 | 0 | ReleaseType::Major => write!(f, "major"), |
28 | 2 | ReleaseType::Minor => write!(f, "minor"), |
29 | 2 | ReleaseType::Patch => write!(f, "patch"), |
30 | | } |
31 | 4 | } |
32 | | } |
33 | | |
34 | | /// All side-effecting operations required by this module. |
35 | | /// |
36 | | /// Each method maps to exactly one external operation, making every step |
37 | | /// independently mockable in tests. |
38 | | pub trait ReleaseSystem { |
39 | | /// Run `git status --porcelain` and return its stdout. |
40 | | /// |
41 | | /// # Errors |
42 | | /// |
43 | | /// Returns an error if the process fails. |
44 | | fn git_status_porcelain(&self) -> Result<String>; |
45 | | |
46 | | /// Return the current git branch name. |
47 | | /// |
48 | | /// # Errors |
49 | | /// |
50 | | /// Returns an error if the process fails. |
51 | | fn git_current_branch(&self) -> Result<String>; |
52 | | |
53 | | /// Create and switch to a new branch with `git checkout -b <name>`. |
54 | | /// |
55 | | /// # Arguments |
56 | | /// |
57 | | /// * `name` - Branch name to create. |
58 | | /// |
59 | | /// # Errors |
60 | | /// |
61 | | /// Returns an error if the process fails. |
62 | | fn git_checkout_new_branch(&self, name: &str) -> Result<()>; |
63 | | |
64 | | /// Stage the given files with `git add`. |
65 | | /// |
66 | | /// # Arguments |
67 | | /// |
68 | | /// * `files` - Paths to stage. |
69 | | /// |
70 | | /// # Errors |
71 | | /// |
72 | | /// Returns an error if the process fails. |
73 | | fn git_add(&self, files: &[String]) -> Result<()>; |
74 | | |
75 | | /// Commit staged changes with the given message. |
76 | | /// |
77 | | /// # Arguments |
78 | | /// |
79 | | /// * `message` - Commit message. |
80 | | /// * `no_verify` - When `true`, pass `--no-verify` to bypass git hooks. |
81 | | /// |
82 | | /// # Errors |
83 | | /// |
84 | | /// Returns an error if the process fails. |
85 | | fn git_commit(&self, message: &str, no_verify: bool) -> Result<()>; |
86 | | |
87 | | /// Run `git push` with the given extra arguments. |
88 | | /// |
89 | | /// # Arguments |
90 | | /// |
91 | | /// * `args` - Extra arguments appended to `git push`. |
92 | | /// |
93 | | /// # Errors |
94 | | /// |
95 | | /// Returns an error if the process fails. |
96 | | fn git_push(&self, args: &[String]) -> Result<()>; |
97 | | |
98 | | /// Return `git tag -l <tag>` stdout for the given tag name. |
99 | | /// |
100 | | /// # Arguments |
101 | | /// |
102 | | /// * `tag` - Tag name to check. |
103 | | /// |
104 | | /// # Errors |
105 | | /// |
106 | | /// Returns an error if the process fails. |
107 | | fn git_tag_list(&self, tag: &str) -> Result<String>; |
108 | | |
109 | | /// Return the subject of the latest commit (`git log -1 --pretty=format:%s`). |
110 | | /// |
111 | | /// # Errors |
112 | | /// |
113 | | /// Returns an error if the process fails. |
114 | | fn git_log_latest_subject(&self) -> Result<String>; |
115 | | |
116 | | /// Run `git fetch`. |
117 | | /// |
118 | | /// # Errors |
119 | | /// |
120 | | /// Returns an error if the process fails (non-fatal; callers may continue). |
121 | | fn git_fetch(&self) -> Result<()>; |
122 | | |
123 | | /// Return the number of commits the local branch is behind `<branch>` on |
124 | | /// the remote. |
125 | | /// |
126 | | /// # Arguments |
127 | | /// |
128 | | /// * `branch` - Remote branch to compare against. |
129 | | /// |
130 | | /// # Errors |
131 | | /// |
132 | | /// Returns an error if the process fails. |
133 | | fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32>; |
134 | | |
135 | | /// Create an annotated git tag. |
136 | | /// |
137 | | /// # Arguments |
138 | | /// |
139 | | /// * `tag` - Tag name. |
140 | | /// * `message` - Annotation message. |
141 | | /// |
142 | | /// # Errors |
143 | | /// |
144 | | /// Returns an error if the process fails. |
145 | | fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()>; |
146 | | |
147 | | /// Push a tag to `origin`. |
148 | | /// |
149 | | /// # Arguments |
150 | | /// |
151 | | /// * `tag` - Tag name to push. |
152 | | /// |
153 | | /// # Errors |
154 | | /// |
155 | | /// Returns an error if the process fails. |
156 | | fn git_push_tag(&self, tag: &str) -> Result<()>; |
157 | | |
158 | | /// Read the contents of `Cargo.toml`. |
159 | | /// |
160 | | /// # Errors |
161 | | /// |
162 | | /// Returns an error if the file cannot be read. |
163 | | fn read_cargo_toml(&self) -> Result<String>; |
164 | | |
165 | | /// Write `content` to `Cargo.toml`. |
166 | | /// |
167 | | /// # Errors |
168 | | /// |
169 | | /// Returns an error if the write fails. |
170 | | fn write_cargo_toml(&self, content: &str) -> Result<()>; |
171 | | |
172 | | /// Run `cargo update --workspace` to refresh `Cargo.lock`. |
173 | | /// |
174 | | /// # Errors |
175 | | /// |
176 | | /// Returns an error if the process fails. |
177 | | fn cargo_update_workspace(&self) -> Result<()>; |
178 | | |
179 | | /// Generate the changelog for the current version. |
180 | | /// |
181 | | /// # Errors |
182 | | /// |
183 | | /// Returns an error if changelog generation fails. |
184 | | fn generate_changelog(&self) -> Result<()>; |
185 | | |
186 | | /// Display `message` and read a line of user input. |
187 | | /// |
188 | | /// # Arguments |
189 | | /// |
190 | | /// * `message` - Prompt text. |
191 | | /// |
192 | | /// # Returns |
193 | | /// |
194 | | /// The trimmed response string. |
195 | | /// |
196 | | /// # Errors |
197 | | /// |
198 | | /// Returns an error if stdin cannot be read. |
199 | | fn prompt_user(&self, message: &str) -> Result<String>; |
200 | | } |
201 | | |
202 | | /// Production implementation of [`ReleaseSystem`]. |
203 | | pub struct RealSystem; |
204 | | |
205 | | #[cfg_attr(coverage_nightly, coverage(off))] |
206 | | impl ReleaseSystem for RealSystem { |
207 | | fn git_status_porcelain(&self) -> Result<String> { |
208 | | let output = std::process::Command::new("git") |
209 | | .args(["status", "--porcelain"]) |
210 | | .output() |
211 | | .context("failed to run `git status --porcelain`")?; |
212 | | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) |
213 | | } |
214 | | |
215 | | fn git_current_branch(&self) -> Result<String> { |
216 | | let output = std::process::Command::new("git") |
217 | | .args(["branch", "--show-current"]) |
218 | | .output() |
219 | | .context("failed to run `git branch --show-current`")?; |
220 | | Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) |
221 | | } |
222 | | |
223 | | fn git_checkout_new_branch(&self, name: &str) -> Result<()> { |
224 | | let status = std::process::Command::new("git") |
225 | | .args(["checkout", "-b", name]) |
226 | | .status() |
227 | | .context("failed to run `git checkout -b`")?; |
228 | | if !status.success() { |
229 | | bail!("`git checkout -b {name}` failed with status {status}"); |
230 | | } |
231 | | Ok(()) |
232 | | } |
233 | | |
234 | | fn git_add(&self, files: &[String]) -> Result<()> { |
235 | | let status = std::process::Command::new("git") |
236 | | .arg("add") |
237 | | .args(files) |
238 | | .status() |
239 | | .context("failed to run `git add`")?; |
240 | | if !status.success() { |
241 | | bail!("`git add` failed with status {status}"); |
242 | | } |
243 | | Ok(()) |
244 | | } |
245 | | |
246 | | fn git_commit(&self, message: &str, no_verify: bool) -> Result<()> { |
247 | | let mut cmd = std::process::Command::new("git"); |
248 | | cmd.args(["commit", "-m", message]); |
249 | | if no_verify { |
250 | | cmd.arg("--no-verify"); |
251 | | } |
252 | | let status = cmd.status().context("failed to run `git commit`")?; |
253 | | if !status.success() { |
254 | | bail!("`git commit` failed with status {status}"); |
255 | | } |
256 | | Ok(()) |
257 | | } |
258 | | |
259 | | fn git_push(&self, args: &[String]) -> Result<()> { |
260 | | let status = std::process::Command::new("git") |
261 | | .arg("push") |
262 | | .args(args) |
263 | | .status() |
264 | | .context("failed to run `git push`")?; |
265 | | if !status.success() { |
266 | | bail!("`git push` failed with status {status}"); |
267 | | } |
268 | | Ok(()) |
269 | | } |
270 | | |
271 | | fn git_tag_list(&self, tag: &str) -> Result<String> { |
272 | | let output = std::process::Command::new("git") |
273 | | .args(["tag", "-l", tag]) |
274 | | .output() |
275 | | .context("failed to run `git tag -l`")?; |
276 | | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) |
277 | | } |
278 | | |
279 | | fn git_log_latest_subject(&self) -> Result<String> { |
280 | | let output = std::process::Command::new("git") |
281 | | .args(["log", "-1", "--pretty=format:%s"]) |
282 | | .output() |
283 | | .context("failed to run `git log`")?; |
284 | | Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) |
285 | | } |
286 | | |
287 | | fn git_fetch(&self) -> Result<()> { |
288 | | let status = std::process::Command::new("git") |
289 | | .arg("fetch") |
290 | | .status() |
291 | | .context("failed to run `git fetch`")?; |
292 | | if !status.success() { |
293 | | bail!("`git fetch` failed with status {status}"); |
294 | | } |
295 | | Ok(()) |
296 | | } |
297 | | |
298 | | fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32> { |
299 | | let output = std::process::Command::new("git") |
300 | | .args(["rev-list", "--count", &format!("HEAD..origin/{branch}")]) |
301 | | .output() |
302 | | .context("failed to run `git rev-list`")?; |
303 | | let count = String::from_utf8_lossy(&output.stdout) |
304 | | .trim() |
305 | | .parse::<u32>() |
306 | | .unwrap_or(0); |
307 | | Ok(count) |
308 | | } |
309 | | |
310 | | fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()> { |
311 | | let status = std::process::Command::new("git") |
312 | | .args(["tag", "-a", tag, "-m", message]) |
313 | | .status() |
314 | | .context("failed to run `git tag -a`")?; |
315 | | if !status.success() { |
316 | | bail!("`git tag -a {tag}` failed with status {status}"); |
317 | | } |
318 | | Ok(()) |
319 | | } |
320 | | |
321 | | fn git_push_tag(&self, tag: &str) -> Result<()> { |
322 | | let status = std::process::Command::new("git") |
323 | | .args(["push", "origin", tag]) |
324 | | .status() |
325 | | .context("failed to run `git push origin <tag>`")?; |
326 | | if !status.success() { |
327 | | bail!("`git push origin {tag}` failed with status {status}"); |
328 | | } |
329 | | Ok(()) |
330 | | } |
331 | | |
332 | | fn read_cargo_toml(&self) -> Result<String> { |
333 | | std::fs::read_to_string("Cargo.toml").context("failed to read Cargo.toml") |
334 | | } |
335 | | |
336 | | fn write_cargo_toml(&self, content: &str) -> Result<()> { |
337 | | std::fs::write("Cargo.toml", content).context("failed to write Cargo.toml") |
338 | | } |
339 | | |
340 | | fn cargo_update_workspace(&self) -> Result<()> { |
341 | | let status = std::process::Command::new("cargo") |
342 | | .args(["update", "--workspace"]) |
343 | | .status() |
344 | | .context("failed to run `cargo update --workspace`")?; |
345 | | if !status.success() { |
346 | | bail!("`cargo update --workspace` failed with status {status}"); |
347 | | } |
348 | | Ok(()) |
349 | | } |
350 | | |
351 | | fn generate_changelog(&self) -> Result<()> { |
352 | | crate::changelog::generate_changelog(&crate::changelog::RealSystem) |
353 | | } |
354 | | |
355 | | fn prompt_user(&self, message: &str) -> Result<String> { |
356 | | use std::io::Write; |
357 | | print!("{message}"); |
358 | | std::io::stdout() |
359 | | .flush() |
360 | | .context("failed to flush stdout")?; |
361 | | let mut input = String::new(); |
362 | | std::io::stdin() |
363 | | .read_line(&mut input) |
364 | | .context("failed to read user input")?; |
365 | | Ok(input.trim().to_owned()) |
366 | | } |
367 | | } |
368 | | |
369 | | /// Determine the suggested next version and release type from the current branch. |
370 | | /// |
371 | | /// `main` → minor bump; `*-maintenance` → patch bump. |
372 | | /// |
373 | | /// # Arguments |
374 | | /// |
375 | | /// * `current` - Current version from `Cargo.toml`. |
376 | | /// * `branch` - Current git branch name. |
377 | | /// |
378 | | /// # Returns |
379 | | /// |
380 | | /// `(ReleaseType, next_version)`. |
381 | | /// |
382 | | /// # Errors |
383 | | /// |
384 | | /// Returns an error when `branch` is neither `main` nor ends with |
385 | | /// `-maintenance`. |
386 | 6 | pub fn suggest_next_version(current: &Version, branch: &str) -> Result<(ReleaseType, Version)> { |
387 | 6 | if branch == "main" { |
388 | 2 | let mut next = current.clone(); |
389 | 2 | next.minor += 1; |
390 | 2 | next.patch = 0; |
391 | 2 | Ok((ReleaseType::Minor, next)) |
392 | 4 | } else if branch.ends_with("-maintenance") { |
393 | 2 | let mut next = current.clone(); |
394 | 2 | next.patch += 1; |
395 | 2 | Ok((ReleaseType::Patch, next)) |
396 | | } else { |
397 | 2 | bail!( |
398 | | "must be on 'main' or a '*-maintenance' branch to prepare a release \ |
399 | | (current branch: {branch})" |
400 | | ) |
401 | | } |
402 | 6 | } |
403 | | |
404 | | /// Determine the release type by comparing two versions. |
405 | | /// |
406 | | /// # Arguments |
407 | | /// |
408 | | /// * `current` - The version before the release. |
409 | | /// * `next` - The version after the release. |
410 | | /// |
411 | | /// # Returns |
412 | | /// |
413 | | /// The most significant component that changed. |
414 | 3 | pub fn determine_release_type(current: &Version, next: &Version) -> ReleaseType { |
415 | 3 | if next.major > current.major { |
416 | 1 | ReleaseType::Major |
417 | 2 | } else if next.minor > current.minor { |
418 | 1 | ReleaseType::Minor |
419 | | } else { |
420 | 1 | ReleaseType::Patch |
421 | | } |
422 | 3 | } |
423 | | |
424 | | /// Rewrite the `[package].version` field in a `Cargo.toml` string. |
425 | | /// |
426 | | /// Uses `toml_edit` to preserve all existing formatting. |
427 | | /// |
428 | | /// # Arguments |
429 | | /// |
430 | | /// * `cargo_toml_content` - Raw TOML text of `Cargo.toml`. |
431 | | /// * `new_version` - Version string to set. |
432 | | /// |
433 | | /// # Returns |
434 | | /// |
435 | | /// Updated TOML text. |
436 | | /// |
437 | | /// # Errors |
438 | | /// |
439 | | /// Returns an error if `cargo_toml_content` cannot be parsed as TOML. |
440 | 4 | pub fn set_cargo_toml_version(cargo_toml_content: &str, new_version: &str) -> Result<String> { |
441 | 4 | let mut doc: toml_edit::Document = cargo_toml_content |
442 | 4 | .parse() |
443 | 4 | .context("failed to parse Cargo.toml")?0 ; |
444 | 4 | doc["package"]["version"] = toml_edit::value(new_version); |
445 | 4 | Ok(doc.to_string()) |
446 | 4 | } |
447 | | |
448 | | /// Prepare a new release. |
449 | | /// |
450 | | /// Full workflow: |
451 | | /// 1. Verify working tree is clean. |
452 | | /// 2. Detect branch and suggest release type / next version. |
453 | | /// 3. Prompt user (accepts custom version input). |
454 | | /// 4. Create maintenance branch if on `main`. |
455 | | /// 5. Update `Cargo.toml` version. |
456 | | /// 6. Run `cargo update --workspace`. |
457 | | /// 7. Generate changelog. |
458 | | /// 8. Commit and push. |
459 | | /// |
460 | | /// # Arguments |
461 | | /// |
462 | | /// * `system` - Injected I/O provider. |
463 | | /// |
464 | | /// # Errors |
465 | | /// |
466 | | /// Returns an error if any step fails. |
467 | 4 | pub fn prepare_release<S: ReleaseSystem>(system: &S) -> Result<()> { |
468 | 4 | let status = system.git_status_porcelain()?0 ; |
469 | 4 | if !status.trim().is_empty() { |
470 | 1 | bail!("git working directory is not clean — commit or stash changes first:\n{status}"); |
471 | 3 | } |
472 | | |
473 | 3 | let current_branch = system.git_current_branch()?0 ; |
474 | 3 | let cargo_toml = system.read_cargo_toml()?0 ; |
475 | 3 | let current_version: Version = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)?0 |
476 | 3 | .parse() |
477 | 3 | .context("failed to parse current version as semver")?0 ; |
478 | | |
479 | 3 | println!("INFO - Current branch: {current_branch}"); |
480 | 3 | println!("INFO - Current version: {current_version}"); |
481 | | |
482 | 2 | let (suggested_type, suggested_version) = |
483 | 3 | suggest_next_version(¤t_version, ¤t_branch)?1 ; |
484 | | |
485 | 2 | let prompt = format!( |
486 | | "Preparing {suggested_type} release: {current_version} -> {suggested_version}. Continue? [Y/n]: " |
487 | | ); |
488 | 2 | let answer = system.prompt_user(&prompt)?0 ; |
489 | | |
490 | 2 | let (next_version, actual_type) = |
491 | 2 | if answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") { |
492 | 0 | let custom_str = system.prompt_user(&format!( |
493 | 0 | "Enter custom version (current: {current_version}): " |
494 | 0 | ))?; |
495 | 0 | if custom_str.is_empty() { |
496 | 0 | bail!("version cannot be empty"); |
497 | 0 | } |
498 | 0 | let custom: Version = custom_str |
499 | 0 | .parse() |
500 | 0 | .context("invalid version format — use semantic versioning (e.g. 1.2.3)")?; |
501 | 0 | let release_type = determine_release_type(¤t_version, &custom); |
502 | 0 | (custom, release_type) |
503 | 2 | } else if answer.is_empty() |
504 | 2 | || answer.eq_ignore_ascii_case("y") |
505 | 0 | || answer.eq_ignore_ascii_case("yes") |
506 | | { |
507 | 2 | (suggested_version, suggested_type) |
508 | | } else { |
509 | 0 | bail!("invalid input — please enter Y or n"); |
510 | | }; |
511 | | |
512 | 2 | let target_branch = if current_branch == "main" { |
513 | 1 | format!("{}.{}-maintenance", next_version.major, next_version.minor) |
514 | | } else { |
515 | 1 | current_branch.clone() |
516 | | }; |
517 | | |
518 | 2 | println!("INFO - Preparing {actual_type} release: {current_version} -> {next_version}"); |
519 | 2 | println!("INFO - Target branch: {target_branch}"); |
520 | | |
521 | 2 | if current_branch == "main" { |
522 | 1 | println!("INFO - Creating maintenance branch: {target_branch}"); |
523 | 1 | system.git_checkout_new_branch(&target_branch)?0 ; |
524 | 1 | } |
525 | | |
526 | 2 | println!("INFO - Updating Cargo.toml version to {next_version}"); |
527 | 2 | let updated_cargo = set_cargo_toml_version(&cargo_toml, &next_version.to_string())?0 ; |
528 | 2 | system.write_cargo_toml(&updated_cargo)?0 ; |
529 | | |
530 | 2 | println!("INFO - Updating Cargo.lock"); |
531 | 2 | system.cargo_update_workspace()?0 ; |
532 | | |
533 | 2 | println!("INFO - Generating changelog"); |
534 | 2 | system.generate_changelog()?0 ; |
535 | | |
536 | 2 | let commit_message = format!("Version {next_version}"); |
537 | 2 | println!("INFO - Committing: {commit_message}"); |
538 | 2 | system.git_add(&[ |
539 | 2 | "Cargo.toml".to_owned(), |
540 | 2 | "Cargo.lock".to_owned(), |
541 | 2 | "CHANGELOG.md".to_owned(), |
542 | 2 | "changelogging.toml".to_owned(), |
543 | 2 | ])?0 ; |
544 | | // Skip pre-commit hooks: the project's hook runs `cargo build --workspace |
545 | | // --all-targets`, which would try to replace the running xtask.exe and |
546 | | // fail on Windows with an access-denied error. |
547 | 2 | system.git_commit(&commit_message, true)?0 ; |
548 | | |
549 | 2 | println!("INFO - Pushing to remote"); |
550 | 2 | if current_branch == "main" { |
551 | 1 | system.git_push(&["-u".to_owned(), "origin".to_owned(), target_branch.clone()])?0 ; |
552 | | } else { |
553 | 1 | system.git_push(&[])?0 ; |
554 | | } |
555 | | |
556 | 2 | println!("INFO - Release {next_version} prepared on branch {target_branch}"); |
557 | 2 | println!("INFO - Run `cargo xtask create-release-tag` to tag the release"); |
558 | 2 | Ok(()) |
559 | 4 | } |
560 | | |
561 | | /// Create and push an annotated git tag for the current release version. |
562 | | /// |
563 | | /// Full workflow: |
564 | | /// 1. Verify on a maintenance branch. |
565 | | /// 2. Read version from `Cargo.toml`. |
566 | | /// 3. Check the tag does not already exist. |
567 | | /// 4. Verify the latest commit message is `"Version X.Y.Z"`. |
568 | | /// 5. Fetch from remote and check not behind. |
569 | | /// 6. Prompt user for confirmation. |
570 | | /// 7. Create annotated tag and push. |
571 | | /// |
572 | | /// # Arguments |
573 | | /// |
574 | | /// * `system` - Injected I/O provider. |
575 | | /// |
576 | | /// # Errors |
577 | | /// |
578 | | /// Returns an error if any validation step fails. |
579 | 6 | pub fn create_release_tag<S: ReleaseSystem>(system: &S) -> Result<()> { |
580 | 6 | let current_branch = system.git_current_branch()?0 ; |
581 | 6 | if !current_branch.ends_with("-maintenance") { |
582 | 1 | bail!( |
583 | | "must be on a maintenance branch to create a release tag \ |
584 | | (current branch: {current_branch}) — run `cargo xtask prepare-release` first" |
585 | | ); |
586 | 5 | } |
587 | | |
588 | 5 | let cargo_toml = system.read_cargo_toml()?0 ; |
589 | 5 | let version_str = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)?0 ; |
590 | 5 | let version: Version = version_str |
591 | 5 | .parse() |
592 | 5 | .context("failed to parse version as semver")?0 ; |
593 | | |
594 | 5 | println!("INFO - Current branch: {current_branch}"); |
595 | 5 | println!("INFO - Version to tag: {version}"); |
596 | | |
597 | 5 | let existing_tag = system.git_tag_list(&version.to_string())?0 ; |
598 | 5 | if !existing_tag.trim().is_empty() { |
599 | 1 | bail!("tag {version} already exists"); |
600 | 4 | } |
601 | | |
602 | 4 | let commit_msg = system.git_log_latest_subject()?0 ; |
603 | 4 | let expected_msg = format!("Version {version}"); |
604 | 4 | if commit_msg != expected_msg { |
605 | 1 | bail!( |
606 | | "latest commit message does not match expected version commit\n\ |
607 | | expected: {expected_msg}\n\ |
608 | | actual: {commit_msg}\n\ |
609 | | run `cargo xtask prepare-release` first" |
610 | | ); |
611 | 3 | } |
612 | | |
613 | 3 | println!("INFO - Fetching latest changes from remote"); |
614 | 3 | if let Err(e0 ) = system.git_fetch() { |
615 | 0 | eprintln!("WARN - Failed to fetch from remote, continuing anyway: {e}"); |
616 | 3 | } |
617 | | |
618 | 3 | let behind = system.git_rev_list_count_behind(¤t_branch)?0 ; |
619 | 3 | if behind > 0 { |
620 | 1 | bail!("local branch is {behind} commit(s) behind remote — run `git pull` first"); |
621 | 2 | } |
622 | | |
623 | 2 | let answer = system.prompt_user(&format!( |
624 | 2 | "About to create and push tag '{version}'. Continue? [Y/n]: " |
625 | 2 | ))?0 ; |
626 | 2 | if answer.eq_ignore_ascii_case("n") || answer1 .eq_ignore_ascii_case("no") { |
627 | 1 | println!("INFO - Tag creation cancelled"); |
628 | 1 | return Ok(()); |
629 | 1 | } |
630 | | |
631 | 1 | let tag_message = format!("Version {version}"); |
632 | 1 | println!("INFO - Creating annotated tag: {version}"); |
633 | 1 | system.git_create_annotated_tag(&version.to_string(), &tag_message)?0 ; |
634 | | |
635 | 1 | println!("INFO - Pushing tag to remote"); |
636 | 1 | system.git_push_tag(&version.to_string())?0 ; |
637 | | |
638 | 1 | println!("INFO - Tag '{version}' created and pushed"); |
639 | 1 | println!("INFO - Check: https://github.com/whme/csshw/actions/workflows/release.yml"); |
640 | 1 | Ok(()) |
641 | 6 | } |
642 | | |
643 | | #[cfg(test)] |
644 | | #[path = "tests/test_release.rs"] |
645 | | mod tests; |